From fbb3b7f05737f9571b04b0a8f4f15c0928de8545 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 7 Jul 2025 01:43:36 +0000 Subject: (대표님) 변경사항 20250707 10시 43분 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/auth/[...nextauth]/route.ts | 258 +++++++++++++++++++++++++----------- 1 file changed, 177 insertions(+), 81 deletions(-) (limited to 'app/api/auth/[...nextauth]/route.ts') diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index f5d49f77..2b168746 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,5 +1,4 @@ -// Updated NextAuth configuration with dynamic session timeout from database - +// auth/config.ts - 업데이트된 NextAuth 설정 import NextAuth, { NextAuthOptions, Session, @@ -9,15 +8,18 @@ import NextAuth, { import { JWT } from "next-auth/jwt" import CredentialsProvider from 'next-auth/providers/credentials' import { SAMLProvider } from './saml/provider' -import { getUserById } from '@/lib/users/repository' +import { getUserByEmail, getUserById } from '@/lib/users/repository' import { authenticateWithSGips, verifyExternalCredentials } from '@/lib/users/auth/verifyCredentails' import { verifyOtpTemp } from '@/lib/users/verifyOtp' import { getSecuritySettings } from '@/lib/password-policy/service' +import { verifySmsToken } from '@/lib/users/auth/passwordUtil' +import { SessionRepository } from '@/lib/users/session/repository' +import { loginSessions } from '@/db/schema' // 인증 방식 타입 정의 type AuthMethod = 'otp' | 'email' | 'sgips' | 'saml' -// 모듈 보강 선언 (인증 방식 추가) +// 모듈 보강 선언 (기존과 동일) declare module "next-auth" { interface Session { user: { @@ -30,7 +32,8 @@ declare module "next-auth" { domain?: string | null reAuthTime?: number | null authMethod?: AuthMethod - sessionExpiredAt?: number | null // 세션 만료 시간 추가 + sessionExpiredAt?: number | null + dbSessionId?: string | null // DB 세션 ID 추가 } } @@ -42,6 +45,7 @@ declare module "next-auth" { domain?: string | null reAuthTime?: number | null authMethod?: AuthMethod + dbSessionId?: string | null } } @@ -54,11 +58,12 @@ declare module "next-auth/jwt" { domain?: string | null reAuthTime?: number | null authMethod?: AuthMethod - sessionExpiredAt?: number | null // 세션 만료 시간 추가 + sessionExpiredAt?: number | null + dbSessionId?: string | null } } -// 보안 설정 캐시 (성능 최적화) +// 보안 설정 캐시 (기존과 동일) let securitySettingsCache: { data: any | null lastFetch: number @@ -69,7 +74,6 @@ let securitySettingsCache: { ttl: 5 * 60 * 1000 // 5분 캐시 } -// 보안 설정을 가져오는 함수 (캐시 적용) async function getCachedSecuritySettings() { const now = Date.now() @@ -80,7 +84,6 @@ async function getCachedSecuritySettings() { securitySettingsCache.lastFetch = now } catch (error) { console.error('Failed to fetch security settings:', error) - // 기본값 사용 securitySettingsCache.data = { sessionTimeoutMinutes: 480 // 8시간 기본값 } @@ -90,11 +93,28 @@ async function getCachedSecuritySettings() { return securitySettingsCache.data } +// 클라이언트 IP 추출 헬퍼 +function getClientIP(req: any): string { + const forwarded = req.headers['x-forwarded-for'] + const realIP = req.headers['x-real-ip'] + + if (forwarded) { + return forwarded.split(',')[0].trim() + } + + if (realIP) { + return realIP + } + + return req.ip || req.connection?.remoteAddress || '127.0.0.1' +} + export const authOptions: NextAuthOptions = { providers: [ - // OTP provider + // OTP 로그인 (기존 유지) CredentialsProvider({ - name: 'Credentials', + id: 'credentials-otp', + name: 'OTP', credentials: { email: { label: 'Email', type: 'text' }, code: { label: 'OTP code', type: 'text' }, @@ -107,9 +127,7 @@ export const authOptions: NextAuthOptions = { return null } - // 보안 설정에서 세션 타임아웃 가져오기 const securitySettings = await getCachedSecuritySettings() - const sessionTimeoutMs = securitySettings.sessionTimeoutMinutes * 60 * 1000 const reAuthTime = Date.now() return { @@ -125,61 +143,101 @@ export const authOptions: NextAuthOptions = { } }, }), - - // ID/패스워드 provider (S-Gips와 일반 이메일 구분) + + // MFA 완료 후 최종 인증 (DB 연동 버전) CredentialsProvider({ - id: 'credentials-password', - name: 'Username Password', + id: 'credentials-mfa', + name: 'MFA Verification', credentials: { - username: { label: "Username", type: "text" }, - password: { label: "Password", type: "password" }, - provider: { label: "Provider", type: "text" }, + userId: { label: 'User ID', type: 'text' }, + smsToken: { label: 'SMS Token', type: 'text' }, + tempAuthKey: { label: 'Temp Auth Key', type: 'text' }, }, async authorize(credentials, req) { - if (!credentials?.username || !credentials?.password) { - return null; + if (!credentials?.userId || !credentials?.smsToken || !credentials?.tempAuthKey) { + console.error('MFA credentials missing') + return null } - + try { - let authResult; - const isSSgips = credentials.provider === 'sgips'; - - if (isSSgips) { - authResult = await authenticateWithSGips( - credentials.username, - credentials.password - ); - } else { - authResult = await verifyExternalCredentials( - credentials.username, - credentials.password - ); + // DB에서 임시 인증 정보 확인 + const tempAuth = await SessionRepository.getTempAuthSession(credentials.tempAuthKey) + if (!tempAuth || tempAuth.userId !== credentials.userId) { + console.error('Temp auth expired or not found') + return null } - - if (authResult.success && authResult.user) { - return { - id: authResult.user.id, - name: authResult.user.name, - email: authResult.user.email, - imageUrl: authResult.user.imageUrl ?? null, - companyId: authResult.user.companyId, - techCompanyId: authResult.user.techCompanyId, - domain: authResult.user.domain, - reAuthTime: Date.now(), - authMethod: isSSgips ? 'sgips' as AuthMethod : 'email' as AuthMethod, - }; + + // SMS 토큰 검증 + const smsVerificationResult = await verifySmsToken(Number(credentials.userId), credentials.smsToken) + if (!smsVerificationResult || !smsVerificationResult.success) { + console.error('SMS token verification failed') + return null } - return null; + // 사용자 정보 조회 + const user = await getUserById(Number(credentials.userId)) + if (!user) { + console.error('User not found after MFA verification') + return null + } + + // 임시 인증 정보를 사용됨으로 표시 + await SessionRepository.markTempAuthSessionAsUsed(credentials.tempAuthKey) + + // 보안 설정 및 세션 정보 설정 + const securitySettings = await getCachedSecuritySettings() + const reAuthTime = Date.now() + const sessionExpiredAt = new Date(reAuthTime + (securitySettings.sessionTimeoutMinutes * 60 * 1000)) + + // DB에 로그인 세션 생성 + const ipAddress = getClientIP(req) + const userAgent = req.headers?.['user-agent'] + const dbSession = await SessionRepository.createLoginSession({ + userId: String(user.id), + ipAddress, + userAgent, + authMethod: tempAuth.authMethod, + sessionExpiredAt, + }) + + console.log(`MFA completed for user ${user.email} (${tempAuth.authMethod})`) + + return { + id: String(user.id), + email: user.email, + imageUrl: user.imageUrl ?? null, + name: user.name, + companyId: user.companyId, + techCompanyId: user.techCompanyId as number | undefined, + domain: user.domain, + reAuthTime, + authMethod: tempAuth.authMethod as AuthMethod, + dbSessionId: dbSession.id, + } + } catch (error) { - console.error("Authentication error:", error); - return null; + console.error('MFA authorization error:', error) + return null } + }, + }), + + // 1차 인증용 프로바이더 (기존 유지) + CredentialsProvider({ + id: 'credentials-first-auth', + name: 'First Factor Authentication', + credentials: { + username: { label: "Username", type: "text" }, + password: { label: "Password", type: "password" }, + provider: { label: "Provider", type: "text" }, + }, + async authorize(credentials, req) { + return null } }), - // SAML Provider + // SAML Provider (기존 유지) SAMLProvider({ id: "credentials-saml", name: "SAML SSO", @@ -199,18 +257,15 @@ export const authOptions: NextAuthOptions = { session: { strategy: 'jwt', - // JWT 기본 maxAge는 30일로 설정하되, 실제 세션 만료는 콜백에서 처리 maxAge: 30 * 24 * 60 * 60, // 30일 }, callbacks: { - // JWT 콜백 - 세션 타임아웃 설정 (만료 체크는 session 콜백에서) async jwt({ token, user, account, trigger, session }) { - // 보안 설정 가져오기 const securitySettings = await getCachedSecuritySettings() const sessionTimeoutMs = securitySettings.sessionTimeoutMinutes * 60 * 1000 - // 최초 로그인 시 + // 최초 로그인 시 (MFA 완료 후) if (user) { const reAuthTime = Date.now() token.id = user.id @@ -223,34 +278,44 @@ export const authOptions: NextAuthOptions = { token.reAuthTime = reAuthTime token.authMethod = user.authMethod token.sessionExpiredAt = reAuthTime + sessionTimeoutMs + token.dbSessionId = user.dbSessionId } - // 인증 방식 결정 (account 정보 기반) - if (account && !token.authMethod) { + // SAML 인증 시 DB 세션 생성 + if (account && account.provider === 'credentials-saml' && token.id) { const reAuthTime = Date.now() - if (account.provider === 'credentials-saml') { + const sessionExpiredAt = new Date(reAuthTime + sessionTimeoutMs) + + try { + const dbSession = await SessionRepository.createLoginSession({ + userId: token.id, + ipAddress: '0.0.0.0', // SAML의 경우 IP 추적 제한적 + authMethod: 'saml', + sessionExpiredAt, + }) + token.authMethod = 'saml' token.reAuthTime = reAuthTime token.sessionExpiredAt = reAuthTime + sessionTimeoutMs - } else if (account.provider === 'credentials') { - // OTP는 이미 user.authMethod에서 설정됨 - if (!token.sessionExpiredAt) { - token.sessionExpiredAt = (token.reAuthTime || Date.now()) + sessionTimeoutMs - } - } else if (account.provider === 'credentials-password') { - // credentials-password는 이미 user.authMethod에서 설정됨 - if (!token.sessionExpiredAt) { - token.sessionExpiredAt = (token.reAuthTime || Date.now()) + sessionTimeoutMs - } + token.dbSessionId = dbSession.id + } catch (error) { + console.error('Failed to create SAML session:', error) } } - // 세션 업데이트 시 (재인증 시간 업데이트) + // 세션 업데이트 시 if (trigger === "update" && session) { if (session.reAuthTime !== undefined) { token.reAuthTime = session.reAuthTime - // 재인증 시간 업데이트 시 세션 만료 시간도 연장 token.sessionExpiredAt = session.reAuthTime + sessionTimeoutMs + + // DB 세션 업데이트 + if (token.dbSessionId) { + await SessionRepository.updateLoginSession(token.dbSessionId, { + lastActivityAt: new Date(), + sessionExpiredAt: new Date(session.reAuthTime + sessionTimeoutMs) + }) + } } if (session.user) { @@ -263,14 +328,18 @@ export const authOptions: NextAuthOptions = { return token }, - // Session 콜백 - 세션 만료 체크 및 정보 포함 async session({ session, token }: { session: Session; token: JWT }) { // 세션 만료 체크 if (token.sessionExpiredAt && Date.now() > token.sessionExpiredAt) { console.log(`Session expired for user ${token.email}. Expired at: ${new Date(token.sessionExpiredAt)}`) - // 만료된 세션 처리 - 빈 세션 반환하여 로그아웃 유도 + + // DB 세션 만료 처리 + if (token.dbSessionId) { + await SessionRepository.logoutSession(token.dbSessionId) + } + return { - expires: new Date(0).toISOString(), // 즉시 만료 + expires: new Date(0).toISOString(), user: null as any } } @@ -287,12 +356,12 @@ export const authOptions: NextAuthOptions = { reAuthTime: token.reAuthTime as number | null, authMethod: token.authMethod as AuthMethod, sessionExpiredAt: token.sessionExpiredAt as number | null, + dbSessionId: token.dbSessionId as string | null, } } return session }, - // Redirect 콜백 async redirect({ url, baseUrl }) { if (url.startsWith("/")) { return `${baseUrl}${url}`; @@ -309,18 +378,45 @@ export const authOptions: NextAuthOptions = { error: '/auth/error', }, - // 디버깅을 위한 이벤트 로깅 events: { async signIn({ user, account, profile }) { const securitySettings = await getCachedSecuritySettings() console.log(`User ${user.email} signed in via ${account?.provider} (authMethod: ${user.authMethod}), session timeout: ${securitySettings.sessionTimeoutMinutes} minutes`); + + // 이미 MFA에서 DB 세션이 생성된 경우가 아니라면 여기서 생성 + if (account?.provider !== 'credentials-mfa' && user.id) { + try { + // 기존 활성 세션 확인 + const existingSession = await SessionRepository.getActiveSessionByUserId(user.id) + if (!existingSession) { + const sessionExpiredAt = new Date(Date.now() + (securitySettings.sessionTimeoutMinutes * 60 * 1000)) + + await SessionRepository.createLoginSession({ + userId: user.id, + ipAddress: '0.0.0.0', // signIn 이벤트에서는 IP 접근 제한적 + authMethod: user.authMethod || 'unknown', + sessionExpiredAt, + }) + } + } catch (error) { + console.error('Failed to create session in signIn event:', error) + } + } }, + async signOut({ session, token }) { console.log(`User ${session?.user?.email || token?.email} signed out`); + + // DB에서 세션 로그아웃 처리 + const userId = session?.user?.id || token?.id + const dbSessionId = session?.user?.dbSessionId || token?.dbSessionId + + if (dbSessionId) { + await SessionRepository.logoutSession(dbSessionId) + } else if (userId) { + // dbSessionId가 없는 경우 사용자의 모든 활성 세션 로그아웃 + await SessionRepository.logoutAllUserSessions(userId) + } } } } - -const handler = NextAuth(authOptions) -export { handler as GET, handler as POST } - -- cgit v1.2.3